iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄系列 第 17

Day 17|Dev Container:先縮小出事範圍,再決定要不要放寬煞車

  • 分享至 

  • xImage
  •  

本日對應公開 Repo:dev-container/

本文依實作當時的建置順序,從最初版 Dockerfile 開始;上方資料夾則是經過後續實戰調整後的目前版本。

Day 17–23:從能跑,到看得見它怎麼跑

簡短回顧

一個 AI agent 在使用者全大寫反覆叮囑「不要再動」之後,刪除了正式環境的資料庫。這件事提醒我:光靠指令約束不夠,還要管住 agent 實際拿到的能力。

前面的日子裡我們把 Code Review 的 Skill 做出來,測完了,也評完了。接下來幾天,講它跑在什麼樣的環境裡,以及我怎麼逐步縮小放手讓它自己跑的風險。

Day 16 結尾埋的那道防火牆,戲份還在後面。今天先把容器的骨架搭起來;真正關得住哪些能力,接下來幾天再逐層驗證。

前言

我們其實都有一種期待是希望 AI Agent 可以獨立執行完畢,期間最好不要一直打斷我、問我問題,例如:是否可以編輯這個檔案、下這個指令等等的。

Claude Code 一開始有提供 --dangerously-skip-permissions,讓 AI 可以呼叫什麼工具就用什麼工具。後來也提供風險相對小的 Auto Mode,由獨立的分類器模型在操作執行前進行審查。

官方文件:

但「放手讓它跑」這件事,業界已經付過幾次學費了:

  • 2025 年 7 月,Replit 的 AI agent 在使用者明確下了 code freeze、甚至全大寫反覆叮囑「不要再動」的情況下,刪除了正式環境資料庫中的資料。這段實驗期間,agent 也曾產生數千筆虛構資料與錯誤測試結果;刪除發生後,它又誤稱 rollback 無法復原,最後由使用者自行操作 rollback 救回。
  • 2025 年 6 月底到 7 月,Google Gemini CLI 的官方 issue tracker 出現兩起資料損失回報。其中一位使用者按下 Allow Always 之後,agent 用 rm -rf 一路刪出專案外,Documents、Downloads、Desktop 全都受到影響。
  • 2026 年 7 月,Matt Shumer 公開自述:OpenAI 的 GPT-5.6 Sol 在 Ultra 模式下誤刪了他 Mac 上幾乎所有檔案。當時他開的是 Full Access。這種事不需要常發生,一次就夠了。

來源:Replit 事件見 Replit 官方回應Ars TechnicaFortune

Gemini CLI 事件見官方 Issue #4586 與 Issue #2617

Matt Shumer 事件見他的原始貼文Full Access 回覆

這三個案例的證據層級不同:Replit 事件有使用者紀錄、媒體報導與公司回應;Gemini CLI 是使用者在官方 issue tracker 提出的回報;Matt Shumer 則是本人公開自述。我把它們視為風險訊號,不拿來證明某個產品或模型必然會出事。

它們真正的共通點是:agent 拿得到足以造成真實損害的能力。Gemini CLI 的 Allow Always 與 Matt Shumer 使用的 Full Access,放寬了逐步確認;Replit 案例裡,agent 則能直接改動正式環境的資料庫。提示詞裡的一句「不要再動」,擋不住已經交出去的權限。

所以我的做法不是「更用力叮囑」,也不是「忍耐一直按允許」,而是換個思路:先把 AI 放進一個損害範圍可控、邊界可以驗證的環境,再決定哪些權限提示可以放寬。這個思路本身不新,業界叫它縱深防禦,「模型的判斷力不能拿來當安全機制」這句話也早就有人講完了。接下來這幾天不打算再論證一次,要做的是親手蓋一遍,然後蓋壞給你看。這就是今天的主角:dev-container

Dev Container

先給全貌。後面幾天講的每一件事(憑證怎麼借、網路開多大、流量怎麼側錄),都是在拆這張圖的某一塊:

Dev Container 的四道邊界:身分、憑證、出網、驗證

Dev Container 的可用示範容器其實 Anthropic 官方已經公開在 GitHub 中,並且有相應的說明文件

我的 Dev Container 就是從這份範例延伸而來。

順著這個往上還有一階。NVIDIA 的 OpenShell 是一套專門把 agent 關起來的 runtime:檔案走 Landlock、行程走 seccomp、出網走它自己的 proxy,政策寫成一份宣告式的檔案。開箱即用的程度很高,這幾道邊界它一次就給齊。

我沒有用它,理由很單純:我這套環境在六月那場分享之前就在跑了。OpenShell 的第一筆 GitHub Release(v0.0.6) 出現在 2026 年 3 月 16 日;截至 2026 年 8 月 25 日,官方仍將它標示為 alpha,GitHub Releases 已累積 93 筆紀錄。當時它還太新,所以我選擇自己動手。而我補的東西也不是照著它那張清單長的:憑證怎麼借、網路開多大、流量怎麼側錄,是接下來這幾天一件一件撞出來的,撞到哪裡補到哪裡。

但也因為是自己補的,我很清楚每一道門是怎麼關的、關到哪裡。用現成工具的代價在另一個地方:你要懂它的設定值。「要不要真的擋」「擋不住的時候要不要繼續跑」通常都是一個參數,而參數有預設值。你以為門有鎖,結果一推開就開了。

Base Image

官方node:20 我這邊改用 ubuntu:24.04

這一條是 hunch,講不出硬理由:我想從一個通用的、不預先綁任何工具的底層開始裝。這顆容器裡要裝的東西很多,node 只是其中一樣,拿 node 的 image 當底,等於先替我決定了一件我還沒決定的事。

工具、套件

以下列出 Skill 會用到的工具,以及容器內常用的基礎工具:

編號 工具名稱 是否固定版本
1 ruff 是(0.16.1
2 ty 是(0.0.66
3 oxlint 是(1.77.0
4 trivy
5 opengrep 是(v1.26.0
6 codegraph
7 Python 3.13 + pydantic 是(pydantic 2.13.4
8 shellcheck 否(隨發行版)
9 Claude Code
10 nvm + Node 24 是(nvm v0.40.6、Node 24)
11 uv
12 rtk
13 mitmproxy 是(12.2.3
14 curl / git / jq / tzdata / vim 否(apt 隨發行版)

Dockerfile 置於 dev-container/ 資料夾下

以下先重現當時的第一版,對應公開 repo 的 d8d5c7e。該版本的 Dockerfile 結尾仍是 CMD ["bash"],所以可以直接用下面的 docker builddocker run 進入 shell。若你要執行目前 main 上的版本,請依資料夾 README 使用 ./build.sh./run-ncr-dev-container.sh

打包 Image

cd dev-container
docker build . -t ncr-dev-container

執行

docker run --rm -it ncr-dev-container

此時的 Dockerfile 結尾還是 CMD ["bash"],所以進去是 shell;本文最後加上 entrypoint 之後,啟動就會直接進 Claude。

進入後啟動 Claude:

選定主題後,登入:

我選訂閱的選項進行登入:

由於我們在 Docker 環境中,它沒辦法幫我真的去開瀏覽器,所以我們自己複製貼到瀏覽器中開啟,完成 OAuth 授權後,它會提供一串代碼:

複製貼回 Claude Code 即完成登入:

關掉 Container 重啟一個

當再次啟動 Container 並開啟 Claude 時會發現又要重新登入了⋯⋯ 😵‍💫

這樣沒有省到時間,所以我們接下來要想想怎麼把登入狀態持久化。

方法一:產生長期 Token

官方文件

claude setup-token

此時會開啟瀏覽器走一次 OAuth 授權流程

完成授權之後,Terminal 就會出現 有效期一年 的 Token:

透過 UI 上呈現的指令將 Token 設定於當前的環境變數中:

export CLAUDE_CODE_OAUTH_TOKEN=<token>

這把 token 有效期一年,但可以撤銷:到 claude.ai 的 Settings → Claude Code → Authorization tokens,把對應那筆刪掉。我實測刪除後,正在跑的 CLI 下一個請求就收到 401 OAuth access token has been revoked.。在清單裡辨識它的方式:setup-token 產生的那筆 scope 只有 user:inference,一般 /login 的會多出 profilefile_upload 等好幾個。

這個撤銷入口目前沒有寫在 authentication 文件裡(截至 2026 年 8 月 25 日),是從設定頁自己找到的。token 本身仍應當密碼對待:直接在終端機貼 export 會讓它以明文進入 shell history;比較穩的做法,是以 600 權限建立只有自己可讀的 token 檔,再從檔案載入 CLAUDE_CODE_OAUTH_TOKEN。這個 token 只能用於模型推論,不能建立 Remote Control session;也帶不動 claude.ai 帳號上的官方 Connector(remote MCP),因為它的 scope 只有 user:inference,沒有 /login 那組的 user:mcp_servers#34575)。自己在 Claude Code 裡設定的 MCP server 不受影響。這個差別,後面講到網路邊界時會撞上一個真實案例。

claude.ai 設定頁的 Authorization tokens 清單:setup-token 產生的那筆 scope 只有 user:inference,右側有刪除鈕

撤銷後的 CLI:前一輪還正常回應,下一個請求收到 401 OAuth access token has been revoked

馬上測試

但如果直接執行 Claude 它會又要你進行初始化設定(選主題跟登入方式),我們可以修改 ~/.claude.json 加入 "hasCompletedOnboarding": true

我的裡面是沒有這個 Key 的,新增後存檔再次啟動 Claude Code 就可使用了!

方法二:直接用本機的

做法上就是直接把本機上的 ~/.claude 資料夾 Mount 進去 Container 中的同一位置(~/.claude)。

要注意:

  1. 若在 Linux,兩者的使用者 uid 例如 1001 要一致

    macOS 的 Docker Desktop 不受此限,VirtioFS 檔案共享會自動映射擁有者。

  2. 若在 macOS,Claude Code 會將憑證置放在作業系統的 Keychain 中,需要解出後再 Mount 入
    # 1. 從 Keychain 解出 OAuth 憑證,寫成 Linux 版 Claude Code 認得的檔案
    #(第一次執行會跳出 Keychain 授權視窗,按「允許」)
    security find-generic-password -s "Claude Code-credentials" -w > ~/.claude/.credentials.json
    chmod 600 ~/.claude/.credentials.json
    

再次啟動,-v 就是將資料夾、檔案 Mount 進去:

docker run --rm -it \
  -v ~/.claude:/home/nathan/.claude \
  -v ~/.claude.json:/home/nathan/.claude.json \
  ncr-dev-container

這個做法優先換取便利,不是最小權限配置。~/.claude~/.claude.json 都會以可寫方式掛入容器,後面的 run script 還會把目前的專案目錄以可寫方式掛入;agent 雖然離開了 host 的其餘檔案系統,仍能修改這幾個明確交出去的路徑。今天先完成可重現的執行環境,憑證、出網與驗證邊界會在後續幾天逐層補上。

啟動 Claude 就可直接使用了 🎉!

容器用完後,視需求把 host 上的明文憑證檔刪掉

(它只是解出來給容器用的複本,macOS 本體用的仍是 Keychain)

rm ~/.claude/.credentials.json

啟動流程簡化

為了少打一些指令以及方便後續功能擴充,我們將啟動的指令寫成 run-ncr-dev-container.sh

插播一下

還記得 Day 5 選 SAST 引擎時說過,Opengrep 是引擎、規則沿用 Semgrep 專案的嗎?這件事在容器裡要還債了。opengrep 的 binary 不帶規則檔,沒有規則它就是一把空槍,SAST 那條軌道會整個空轉。所以規則得從 host 餵進來,先把它 clone 下來:

# 你可以依自己的需求調整規則 repo 的存放路徑
mkdir -p ~/Projects
git clone --depth 1 https://github.com/semgrep/semgrep-rules.git ~/Projects/semgrep-rules

run script 預設會去 $HOME/semgrep-rules 找。若照上面的範例放在 $HOME/Projects/semgrep-rules,啟動前請設定 NCR_OPENGREP_RULES=$HOME/Projects/semgrep-rules;你也可以依自己的需求改成其他路徑。

Run script 每次啟動前會對這份 clone 做一次 best-effort 的 git pull(離線或失敗就沿用現有版本,不擋啟動),然後以唯讀(:ro)Mount 進容器。

一個授權上的提醒:semgrep-rules 採 Semgrep Rules License,限內部使用。自己 clone 來給自己 review 用沒有問題,但不要把規則重新散布,或包進你對外釋出的產品裡。

同一格還要再放一個東西。trivy 也是 binary 不帶資料的:弱點資料庫要去 ghcr.io 抓,下載約 60MB,解開後落地超過 1GB。容器用完即丟,每場重抓重解一次划不來,所以 DB 同樣由容器外面供給。差別是這個不用手動 clone,run script 啟動前會自己更新一顆共用的 docker volume 再掛進去,更新失敗就沿用上一版。後面有一天我會把容器的網路整個鎖起來,到那天這份 cache 就不只是省時間的了。

本日小結

走到本文這個 checkpoint,dev-container/ 已經形成三個核心檔案。公開 repo 的目前版本還包含後續幾天加入的防火牆、驗證與觀測元件;這裡先只整理今天建立的部分。

Dockerfile

定義這個審查環境「有什麼」。

從 Ubuntu 24.04 出發,裝進兩類東西:審查工具鏈(ruff、ty、oxlint、trivy、opengrep、codegraph、shellcheck、pydantic)和基礎設施(Claude Code、Node、uv、rtk、vim、UTF-8 locale、台北時區)。

run-ncr-dev-container.sh

每次啟動時跑:決定什麼東西可以搬進去

在本文這個階段,它負責跨越 host 與容器邊界的三件事。

第一是憑證:CLAUDE_CODE_OAUTH_TOKEN 環境變數優先,其次 macOS 從 Keychain 解出、Linux 直接用登入過落地的憑證檔,三者皆無就直接退出,不啟動一個註定登不進去的容器。

第二是 Opengrep 規則:啟動前對 host 的 semgrep-rules clone 做 best-effort 更新,再以唯讀方式 mount 進去。

第三是 Trivy 的弱點資料庫:啟動前先在容器外更新那顆共用的 cache volume,再掛進容器給掃描用。最後把你當前所在的專案資料夾掛進容器的工作目錄,docker run 起來。

後續修補:Mount 解掉登入問題,卻讓 symlink 失效

~/.claude 掛進容器,解決了登入狀態無法持久化的問題,卻也生成了新的問題。skill 是用 symlink 連進 ~/.claude/skills 的;掛載後,那個 symlink 仍指向 host 上的絕對路徑,在容器裡便成了斷鏈,skill 根本載入不了。

修法是把 symlink 的目標目錄用同一個絕對路徑唯讀掛進容器:路徑一致,symlink 原地復活,agents/ 底下的檔案也跟著活。唯讀是刻意的:skill 是 agent 要遵守的規則,容器裡的它不該改得動。這正是這幾天反覆出現的情況:解掉一個問題之後,新的邊界又會生成下一個問題。

entrypoint.sh

Run wrapper 成形之後,我才把啟動 Claude 的最後幾步收進 entrypoint.sh;它不是最初版 Dockerfile 一開始就有的。

容器內,每次啟動時跑:啟動儀式

在本文這個階段,它是容器裡的第一個程序,負責三件小事:報告 image 是哪個時間點蓋的(判斷環境新舊)、檢查憑證有沒有真的進來(沒有就先警告,免得進去才發現要登入)、然後 exec 交棒給預設指令 claude --dangerously-skip-permissions

這一行只是不再逐次詢問;能不能安全地放寬煞車,仍取決於接下來逐層補上的外部邊界。

容器蓋好了,但 skill 審查要打 GitLab 的 API,token 要不要跟著進容器?明天講這題。


上一篇
Day 16|Skill 的疤痕生命週期:規則什麼時候該拿掉?
下一篇
Day 18|憑證不進 Session:NGINX 反向代理幫 AI 蓋 GitLab token 的章
系列文
AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言